{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Plot Frontend\n",
    "\n",
    "Plots statistics and data collected from the frontend related to feature detection,\n",
    "RANSAC pose recovery, sparse stereo matching and timing."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import yaml\n",
    "import os\n",
    "import copy\n",
    "import pandas as pd\n",
    "import numpy as np\n",
    "from scipy.spatial.transform import Rotation as R\n",
    "\n",
    "import logging\n",
    "log = logging.getLogger(__name__)\n",
    "log.setLevel(logging.INFO)\n",
    "if not log.handlers:\n",
    "    ch = logging.StreamHandler()\n",
    "    ch.setLevel(logging.INFO)\n",
    "    ch.setFormatter(logging.Formatter('%(levelname)s - %(message)s'))\n",
    "    log.addHandler(ch)\n",
    "    \n",
    "from evo.tools import file_interface\n",
    "from evo.tools import plot\n",
    "from evo.tools import pandas_bridge\n",
    "\n",
    "from evo.core import sync\n",
    "from evo.core import trajectory\n",
    "from evo.core import metrics\n",
    "from evo.core import transformations\n",
    "from evo.core import lie_algebra as lie\n",
    "\n",
    "import plotly.graph_objects as go\n",
    "\n",
    "import evaluation.tools as evt\n",
    "from evaluation.evaluation_lib import (\n",
    "    get_ape_trans, \n",
    "    get_ape_rot, \n",
    "    plot_metric, \n",
    "    convert_abs_traj_to_rel_traj, \n",
    "    convert_rel_traj_from_body_to_cam\n",
    ")\n",
    "\n",
    "%matplotlib inline\n",
    "# %matplotlib notebookw_T_bi\n",
    "import matplotlib.pyplot as plt"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Data Locations\n",
    "\n",
    "Make sure to set the following paths.\n",
    "\n",
    "`vio_output_dir` is the path to the directory containing `output_*.csv` files obtained from logging a run of SparkVio.\n",
    "\n",
    "`gt_data_file` is the absolute path to the `csv` file containing ground truth data for the absolute pose at each timestamp of the dataset."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Define directory to VIO output csv files as well as ground truth absolute poses.\n",
    "vio_output_dir = \"\"\n",
    "gt_data_file = \"\"\n",
    "left_cam_calibration_file = \"\""
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Frontend Statistics\n",
    "\n",
    "Calculate and plot important statistics from the frontend of the VIO module\n",
    "\n",
    "These statistics include the number of tracked and detected features, data relating the RANSAC runs for both mono 5-point and stereo 3-point methods, timing data and sparse-stereo-matching statistics."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Parse frontend statistics csv file.\n",
    "stats_file = os.path.join(os.path.expandvars(vio_output_dir), \"output_frontend_stats.csv\")\n",
    "\n",
    "# Convert to tidy pandas DataFrame object.\n",
    "df_stats = pd.read_csv(stats_file, sep=',', index_col=False)\n",
    "df_stats.head()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "scrolled": false
   },
   "outputs": [],
   "source": [
    "# Helper functions for processing data summary.\n",
    "def get_mean(attrib):\n",
    "    ls = df_stats[attrib].tolist()\n",
    "    return float(sum(ls)) / len(ls)\n",
    "\n",
    "def get_min(attrib):\n",
    "    return min(df_stats[attrib])\n",
    "\n",
    "def get_max(attrib):\n",
    "    return max(df_stats[attrib])\n",
    "\n",
    "# Construct and visualize summary. TODO(marcus): use a LaTeX table.\n",
    "summary_stats = [\n",
    "    (\"Average number of detected features\", get_mean(\"nrDetectedFeatures\")),\n",
    "    (\"Minimum number of detected features\", get_min(\"nrDetectedFeatures\")),\n",
    "    (\"Average number of tracked features\" , get_mean(\"nrTrackerFeatures\")),\n",
    "    (\"Minimum number of tracked features\", get_min(\"nrTrackerFeatures\")),\n",
    "    (\"Average number of mono ransac inliers\", get_mean(\"nrMonoInliers\")),\n",
    "    (\"Minimum number of mono ransac inliers\", get_min(\"nrMonoInliers\")),\n",
    "    (\"Average number of stereo ransac inliers\", get_mean(\"nrStereoInliers\")),\n",
    "    (\"Minimum number of stereo ransac inliers\", get_min(\"nrStereoInliers\")),\n",
    "    (\"Average number of mono ransac putatives\", get_mean(\"nrMonoPutatives\")),\n",
    "    (\"Minimum number of mono ransac putatives\", get_min(\"nrMonoPutatives\")),\n",
    "    (\"Average number of stereo ransac putatives\", get_mean(\"nrStereoPutatives\")),\n",
    "    (\"Minimum number of stereo ransac putatives\", get_min(\"nrStereoPutatives\")),\n",
    "]\n",
    "\n",
    "attrib_len = [len(attrib[0]) for attrib in summary_stats]\n",
    "max_attrib_len = max(attrib_len)\n",
    "\n",
    "print(\"\\nStatistic summary:\\n\")\n",
    "for entry in summary_stats:\n",
    "    attrib = entry[0]\n",
    "    value = entry[1]\n",
    "    spacing = max_attrib_len - len(attrib)\n",
    "    print(attrib + \" \"*spacing + \": \" + str(value))"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "scrolled": false
   },
   "outputs": [],
   "source": [
    "# Plot feature tracking statistics.\n",
    "use_plotly = False\n",
    "\n",
    "if not use_plotly:\n",
    "    fig0, axes0 = plt.subplots(nrows=1, ncols=1, figsize=(18,10), squeeze=False)\n",
    "    df_stats.plot(kind=\"line\", y=\"nrDetectedFeatures\", ax=axes0[0,0])\n",
    "    df_stats.plot(kind=\"line\", y=\"nrTrackerFeatures\", ax=axes0[0,0])\n",
    "    plt.show()\n",
    "else:\n",
    "    evt.draw_feature_tracking_stats(df_stats, True)\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "scrolled": false
   },
   "outputs": [],
   "source": [
    "# Plot ransac inlier, putative and iteration statistics.\n",
    "if not use_plotly:\n",
    "    fig1, axes1 = plt.subplots(nrows=1, ncols=3, figsize=(18,10), squeeze=False)\n",
    "    df_stats.plot(kind=\"line\", y=\"nrMonoInliers\", ax=axes1[0,0])\n",
    "    df_stats.plot(kind=\"line\", y=\"nrMonoPutatives\", ax=axes1[0,0])\n",
    "    df_stats.plot(kind=\"line\", y=\"nrStereoInliers\", ax=axes1[0,1])\n",
    "    df_stats.plot(kind=\"line\", y=\"nrStereoPutatives\", ax=axes1[0,1])\n",
    "    df_stats.plot(kind=\"line\", y=\"monoRansacIters\", ax=axes1[0,2])\n",
    "    df_stats.plot(kind=\"line\", y=\"stereoRansacIters\", ax=axes1[0,2])\n",
    "    plt.show()\n",
    "else:\n",
    "    evt.draw_mono_stereo_inliers_outliers(df_stats, True)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "scrolled": false
   },
   "outputs": [],
   "source": [
    "# Plot sparse-stereo-matching statistics.\n",
    "\n",
    "fig3, axes3 = plt.subplots(nrows=1, ncols=4, figsize=(18,10), squeeze=False)\n",
    "\n",
    "df_stats.plot(kind=\"line\", y=\"nrValidRKP\", ax=axes3[0,0])\n",
    "df_stats.plot(kind=\"line\", y=\"nrNoLeftRectRKP\", ax=axes3[0,1])\n",
    "df_stats.plot(kind=\"line\", y=\"nrNoRightRectRKP\", ax=axes3[0,1])\n",
    "df_stats.plot(kind=\"line\", y=\"nrNoDepthRKP\", ax=axes3[0,2])\n",
    "df_stats.plot(kind=\"line\", y=\"nrFailedArunRKP\", ax=axes3[0,3])\n",
    "\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "scrolled": false
   },
   "outputs": [],
   "source": [
    "# Plot timing statistics.\n",
    "if not use_plotly:\n",
    "    fig2, axes2 = plt.subplots(nrows=1, ncols=5, figsize=(18,10), squeeze=False)\n",
    "    df_stats.plot(kind=\"line\", y=\"featureDetectionTime\", ax=axes2[0,0])\n",
    "    df_stats.plot(kind=\"line\", y=\"featureTrackingTime\", ax=axes2[0,1])\n",
    "    df_stats.plot(kind=\"line\", y=\"monoRansacTime\", ax=axes2[0,2])\n",
    "    df_stats.plot(kind=\"line\", y=\"stereoRansacTime\", ax=axes2[0,3])\n",
    "    df_stats.plot(kind=\"line\", y=\"featureSelectionTime\", ax=axes2[0,4])\n",
    "    plt.show()\n",
    "else:\n",
    "    evt.draw_frontend_timing(df_stats, True)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Frontend Mono RANSAC\n",
    "\n",
    "This section shows the performance of mono RANSAC portion of the pipeline.\n",
    "\n",
    "We import the csv data as Pandas DataFrame objects and perform our own data association. Relative poses for ground truth data are computed explicitly here. Rotation error and translation error (up to a scaling factor) are then calculated for each pair of consecutive keyframes.\n",
    "\n",
    "This gives insight into the accuracy of the RANSAC 5-point method employed in the frontend.\n",
    "\n",
    "NOTE: gt_df is read from the ground-truth csv. It expects the timestamp to be the first column. Make sure to comment out `rename_euroc_gt_df(gt_df)` in the second cell below if you are not using a csv with the EuRoC header."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "scrolled": false
   },
   "outputs": [],
   "source": [
    "# Load ground truth and estimated data as csv DataFrames.\n",
    "gt_df = pd.read_csv(gt_data_file, sep=',', index_col=0)\n",
    "\n",
    "ransac_mono_filename = os.path.join(os.path.expandvars(vio_output_dir), \"output_frontend_ransac_mono.csv\")\n",
    "mono_df = pd.read_csv(ransac_mono_filename, sep=',', index_col=0)\n",
    "\n",
    "# Load calibration data\n",
    "with open(left_cam_calibration_file) as f:\n",
    "    f.readline() # skip first line\n",
    "    left_calibration_data = yaml.safe_load(f)\n",
    "    body_T_leftCam = np.reshape(np.array(left_calibration_data['T_BS']['data']), (4,4))\n",
    "    print(\"Left cam calibration matrix: \")\n",
    "    print(body_T_leftCam)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "gt_df = gt_df[~gt_df.index.duplicated()]"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "scrolled": true
   },
   "outputs": [],
   "source": [
    "# Generate some trajectories for later plots\n",
    "# Convert to evo trajectory objects\n",
    "traj_ref_unassociated = pandas_bridge.df_to_trajectory(gt_df)\n",
    "\n",
    "# Use the mono ransac file as estimated trajectory.\n",
    "traj_est_unassociated = pandas_bridge.df_to_trajectory(mono_df)\n",
    "\n",
    "# Associate the trajectories\n",
    "traj_ref_abs, traj_est_rel = sync.associate_trajectories(traj_ref_unassociated, traj_est_unassociated)\n",
    "\n",
    "traj_ref_rel = convert_abs_traj_to_rel_traj(traj_ref_abs, up_to_scale=False)\n",
    "\n",
    "# Transform the relative gt trajectory from body to left camera frame\n",
    "traj_ref_cam_rel =  convert_rel_traj_from_body_to_cam(traj_ref_rel, body_T_leftCam)\n",
    "\n",
    "# Remove the first timestamp; we don't have relative pose at first gt timestamp\n",
    "traj_est_rel = trajectory.PoseTrajectory3D(traj_est_rel._positions_xyz[1:],\n",
    "                                           traj_est_rel._orientations_quat_wxyz[1:],\n",
    "                                           traj_est_rel.timestamps[1:])\n",
    "\n",
    "print \"traj_ref_rel: \", traj_ref_rel\n",
    "print \"traj_ref_cam_rel: \", traj_ref_cam_rel\n",
    "print \"traj_est_rel: \", traj_est_rel\n",
    "\n",
    "# Frames of trajectories:\n",
    "# traj_rel_rel: body frame relative poses\n",
    "# traj_ref_cam_rel: left camera frame relative poses\n",
    "# traj_est_rel: left camera frame relative poses\n",
    "\n",
    "# Save this relative-pose ground truth file to disk as a csv for later use, if needed.\n",
    "# gt_rel_filename = \"/home/marcus/output_gt_rel_poses_mono.csv\"\n",
    "# gt_rel_df.to_csv(filename, sep=',', columns=['x', 'y', 'z', 'qw', 'qx', 'qy', 'qz'])"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Frontend Mono and GT Relative Angles\n",
    "This plot shows the relative angles from one frame to another from both mono RANSAC and ground-truth data. Note that the magnitudes of both lines should align very closely with each other. This plot is not affected by extrinsic calibration (as it is showing the relative angles). It can be used as an indicator for whether mono RANSAC is underestimating/overestimating the robot's rotations."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Plot the mono ransac angles\n",
    "mono_ransac_angles = []\n",
    "mono_ransac_angles_timestamps = []\n",
    "for i in range(len(traj_est_rel._orientations_quat_wxyz)):\n",
    "    mono_ransac_angles_timestamps.append(traj_est_rel.timestamps[i])\n",
    "    # quaternion to axisangle\n",
    "    quat = traj_est_rel._orientations_quat_wxyz[i]\n",
    "    r = R.from_quat([quat[1], quat[2], quat[3], quat[0]])\n",
    "    rot_vec = r.as_rotvec()\n",
    "    mono_ransac_angles.append(np.linalg.norm(rot_vec))\n",
    "\n",
    "\n",
    "# Plot the GT angles\n",
    "gt_angles = []\n",
    "gt_angles_timestamps = []\n",
    "for i in range(len(traj_ref_rel._poses_se3)):\n",
    "    gt_angles_timestamps.append(traj_ref_rel.timestamps[i])\n",
    "    # rotation matrix to axisangle\n",
    "    rotm = traj_ref_rel._poses_se3[i][0:3,0:3]\n",
    "    r = R.from_dcm(rotm)\n",
    "    \n",
    "    rot_vec = r.as_rotvec()\n",
    "    gt_angles.append(np.linalg.norm(rot_vec))\n",
    "\n",
    "    \n",
    "plt.figure(figsize=(18, 10))\n",
    "plt.plot(mono_ransac_angles_timestamps, mono_ransac_angles, 'r', label='Mono ransac')\n",
    "plt.plot(gt_angles_timestamps, gt_angles, 'b',  label='GT')\n",
    "plt.legend(loc='upper right')\n",
    "ax = plt.gca()\n",
    "ax.set_xlabel('Timestamps')\n",
    "ax.set_ylabel('Relative Angles [rad]')\n",
    "\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Mono Relative-pose Errors (RPE)\n",
    "\n",
    "Calculate relative-pose-error (RPE) for the mono ransac poses obtained in the frontend.\n",
    "\n",
    "These are relative poses between keyframes and do not represent an entire trajectory. As such, they cannot be processed using the normal EVO evaluation pipeline.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Get RPE for entire relative trajectory.\n",
    "ape_rot = get_ape_rot((traj_ref_cam_rel, traj_est_rel))\n",
    "ape_tran = get_ape_trans((traj_ref_cam_rel, traj_est_rel))\n",
    "\n",
    "# calculate the translation errors up-to-scale\n",
    "trans_errors = []\n",
    "for i in range(len(traj_ref_cam_rel.timestamps)):\n",
    "    \n",
    "    # normalized translation vector from gt\n",
    "    t_ref = traj_ref_cam_rel.poses_se3[i][0:3,3]\n",
    "    if np.linalg.norm(t_ref) > 1e-6:\n",
    "        t_ref /= np.linalg.norm(t_ref)\n",
    "    \n",
    "    # normalized translation vector from mono ransac\n",
    "    t_est = traj_est_rel.poses_se3[i][0:3,3] \n",
    "    if np.linalg.norm(t_est) > 1e-6:\n",
    "        t_est /= np.linalg.norm(t_est)\n",
    "        \n",
    "    # calculate error (up to scale, equivalent to the angle between the two translation vectors)\n",
    "    trans_errors.append(np.linalg.norm(t_ref - t_est))\n",
    "    \n",
    "plt.figure(figsize=(18, 10))\n",
    "plt.plot(traj_ref_cam_rel.timestamps, trans_errors)\n",
    "plt.xlim(3370, 3450)\n",
    "plt.ylim(0, 0.17)\n",
    "\n",
    "ax = plt.gca()\n",
    "ax.set_xlabel('Timestamps')\n",
    "ax.set_ylabel('Relative Translation Errors')\n",
    "\n",
    "plt.show()\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "scrolled": false
   },
   "outputs": [],
   "source": [
    "# Plot RPE of trajectory rotation and translation parts.\n",
    "seconds_from_start = [t - traj_est_rel.timestamps[0] for t in traj_est_rel.timestamps]\n",
    "\n",
    "fig1 = plot_metric(ape_rot, \"Mono Ransac RPE Rotation Part (degrees)\", figsize=(18,10))\n",
    "#fig2 = plot_metric(ape_tran, \"Mono Ransac RPE Translation Part (meters)\", figsize=(18,10))\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Frontend Stereo RANSAC Poses (RPE)\n",
    "\n",
    "Calculate relative-pose-error (RPE) for the stereo ransac poses obtained in the frontend.\n",
    "\n",
    "This is done in the same way as in the mono module.\n",
    "\n",
    "This gives insight into the accuracy of the RANSAC 3-point method employed in the frontend.\n",
    "\n",
    "NOTE: gt_df is read from the ground-truth csv. It expects the timestamp to be the first column. Make sure to comment out `rename_euroc_gt_df(gt_df)` in the second cell below if you are not using a csv with the EuRoC header."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Load ground truth and estimated data as csv DataFrames.\n",
    "gt_df = pd.read_csv(gt_data_file, sep=',', index_col=0)\n",
    "\n",
    "ransac_stereo_filename = os.path.join(os.path.expandvars(vio_output_dir), \"output_frontend_ransac_stereo.csv\")\n",
    "stereo_df = pd.read_csv(ransac_stereo_filename, sep=',', index_col=0)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "gt_df = gt_df[~gt_df.index.duplicated()]"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Convert to evo trajectory objects\n",
    "traj_ref_unassociated = pandas_bridge.df_to_trajectory(gt_df)\n",
    "\n",
    "# Use the mono ransac file as estimated trajectory.\n",
    "traj_est_unassociated = pandas_bridge.df_to_trajectory(stereo_df)\n",
    "\n",
    "# Associate the trajectories\n",
    "traj_ref_abs, traj_est_rel = sync.associate_trajectories(traj_ref_unassociated, traj_est_unassociated)\n",
    "\n",
    "traj_ref_rel = convert_abs_traj_to_rel_traj(traj_ref_abs)\n",
    "\n",
    "# Remove the first timestamp; we don't have relative pose at first gt timestamp\n",
    "traj_est_rel = trajectory.PoseTrajectory3D(traj_est_rel._positions_xyz[1:],\n",
    "                                           traj_est_rel._orientations_quat_wxyz[1:],\n",
    "                                           traj_est_rel.timestamps[1:])\n",
    "\n",
    "print \"traj_ref_rel: \", traj_ref_rel\n",
    "print \"traj_est_rel: \", traj_est_rel\n",
    "\n",
    "# Convert the absolute poses (world frame) of the gt DataFrame to relative poses.\n",
    "\n",
    "# Save this relative-pose ground truth file to disk as a csv for later use, if needed.\n",
    "# gt_rel_filename = \"/home/marcus/output_gt_rel_poses_stereo.csv\"\n",
    "# gt_rel_df.to_csv(filename, sep=',', columns=['x', 'y', 'z', 'qw', 'qx', 'qy', 'qz'])"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Get RPE for entire relative trajectory.\n",
    "rpe_rot = get_ape_rot((traj_ref_rel, traj_est_rel))\n",
    "rpe_tran = get_ape_trans((traj_ref_rel, traj_est_rel))"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Plot RPE of trajectory rotation and translation parts.\n",
    "seconds_from_start = [t - traj_est_rel.timestamps[0] for t in traj_est_rel.timestamps]\n",
    "\n",
    "plot_metric(rpe_rot, \"Stereo Ransac RPE Rotation Part (degrees)\", figsize=(18,10))\n",
    "plot_metric(rpe_tran, \"Stereo Ransac RPE Translation Part (meters)\", figsize=(18,10))\n",
    "plt.show()"
   ]
  }
 ],
 "metadata": {
  "hide_input": false,
  "kernelspec": {
   "display_name": "Python 2",
   "language": "python",
   "name": "python2"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 2
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython2",
   "version": "2.7.17"
  },
  "latex_envs": {
   "LaTeX_envs_menu_present": true,
   "autoclose": false,
   "autocomplete": false,
   "bibliofile": "biblio.bib",
   "cite_by": "apalike",
   "current_citInitial": 1,
   "eqLabelWithNumbers": true,
   "eqNumInitial": 1,
   "hotkeys": {
    "equation": "Ctrl-E",
    "itemize": "Ctrl-I"
   },
   "labels_anchors": false,
   "latex_user_defs": false,
   "report_style_numbering": false,
   "user_envs_cfg": false
  },
  "toc": {
   "base_numbering": 1,
   "nav_menu": {},
   "number_sections": true,
   "sideBar": true,
   "skip_h1_title": false,
   "title_cell": "Table of Contents",
   "title_sidebar": "Contents",
   "toc_cell": false,
   "toc_position": {},
   "toc_section_display": true,
   "toc_window_display": false
  }
 },
 "nbformat": 4,
 "nbformat_minor": 2
}
